from collections import deque
from bisect import bisect

from khronos.utils import Deque
from khronos.statistics.tally import Tally
from khronos.statistics.plotter import get_plotter

class TSeries(Tally):
    def __init__(self, time_fnc=None, numeric=True, storing=True):
        Tally.__init__(self, numeric=numeric, storing=storing)
        self.__time_fnc = time_fnc
        self.__times = deque() if self.storing else None
        self.__start_time = None
        self.__last_time = None
        self.__last_value = None
        
    def clear(self, time_fnc=None, numeric=None, storing=None):
        if time_fnc is None: time_fnc  = self.__time_fnc
        if numeric  is None: numeric   = self.numeric
        if storing  is None: storing   = self.storing
        self.__init__(time_fnc=time_fnc, numeric=numeric, storing=storing)
        
    def collect(self, value, time=None):
        """Collect a new value into the time series. This method works for initialized and 
        uninitialized TSeries, without requiring the caller to know the object's state."""
        (self.append if self.started else self.start)(value, time)
        
    def start(self, value, time=None):
        """Clear the time series and register a new starting value. Be careful when using this 
        method, since any values previously stored in the time series are discarded!"""
        if self.started:
            self.clear()
        if time is None:
            time = self.__time_fnc()
        if self.storing:
            self.__times.append(time)
        self.__start_time = time
        self.__last_time = time
        self.__last_value = value
        Tally.collect(self, value, weight=0.0)
        
    def append(self, value, time=None):
        """Register a new value into the time series. Note that the time series must be started 
        before calling this method. The collect() method can be used without this concern."""
        if time is None:
            time = self.__time_fnc()
        if time < self.__last_time:
            raise ValueError("invalid time (before previous time)")
        Tally._add_weight(self, self.__last_value, time - self.__last_time)
        if self.storing:
            self.__times.append(time)
        self.__last_time = time
        self.__last_value = value
        Tally.collect(self, value, weight=0.0)
        
    def repeat(self, time=None):
        """Collect the last value again at the given time. This is useful to 'close' a time 
        series at the specified time."""
        self.append(self.__last_value, time)
        
    # -----------------------------------------------
    # Properties
    @property
    def last_time(self):
        return self.__last_time
        
    @property
    def last_value(self):
        return self.__last_value
        
    @property
    def started(self):
        return self.__start_time is not None
        
    @property
    def time_fnc(self):
        return self.__time_fnc
        
    @time_fnc.setter
    def time_fnc(self, fnc):
        self.__time_fnc = fnc
        
    # -----------------------------------------------
    # Container methods
    def __getitem__(self, time):
        """Get the value of the time series at the specified time."""
        if not self.storing:
            raise ValueError("storing time series required")
        if time < self.__start_time:
            raise ValueError("no data before %s" % (self.__start_time,))
        if time > self.__last_time:
            raise ValueError("no data after %s" % (self.__last_time,))
        if time == self.__last_time:
            return self.__last_value
        index = bisect(self.__times, time) - 1
        return Tally.__getitem__(self, index)[0]
        
    def __iter__(self):
        """This iterator returns (value, time) tuples. The meaning is that the time series was 
        changed to 'value' at 'time'."""
        start = self.__start_time
        for value, length in Tally.__iter__(self):
            yield value, start
            start += length
            
    def iter_values(self):
        for value, _ in self:
            yield value
            
    def iter_times(self):
        for _, time in self:
            yield time
            
    # -----------------------------------------------
    # Some indicators
    def length(self):
        return self.__last_time - self.__start_time
        
    def limits(self):
        return self.__start_time, self.__last_time
        
    # -----------------------------------------------
    # Creation of other time series
    def slice(self, start=None, end=None):
        if start is None:
            start = self.__start_time
        if end is None:
            end = self.__last_time
        return TSeries.join(lambda v, t: v[0] if start <= t <= end else None, self)
        
    @staticmethod
    def join(join_fnc, *tseries, **kw_tseries):
        """Join a list of TSeries objects using a join function to aggregate the values of all 
        active time series. For example, to create a time series corresponding to the sum of two
        others (t1 and t2), we could do
            t3 = TSeries.join(lambda x, t: sum(x.values()), t1, t2)
        A key is associated to each tseries object. Numbers are associated to non-keyword 
        arguments, and strings are associated to tseries passed as keyword arguments. For example,
        to multiply a tseries by the other one:
            t3 = TSeries.join(lambda x, t: x[0] * x[1], t1, t2)
                or an exponential
            t3 = TSeries.join(lambda x, t: x[base] ** x[exponent], base=t1, exponent=t2)
            
        The join function should take two arguments, which consist of the mapping of keys of the 
        TSeries objects to its corresponding values at a given instant, and the time at which 
        those values were found. It should return the value to be inserted into the aggregate 
        time series, or None if no value is to be inserted."""
        # Create a map {key: tseries} and perform some checks
        tseries = dict(enumerate(tseries))
        tseries.update(kw_tseries)
        if not all(ts.storing for ts in tseries.itervalues()):
            raise ValueError("all time series need to be storing")
        numeric = [ts.numeric for ts in tseries.itervalues()]
        if not (all(numeric) or not any(numeric)):
            raise ValueError("all time series need to agree on the 'numeric' flag")
        # Create and initialize an event schedule and the aggregate tseries
        aggregate = TSeries(numeric=numeric[0], storing=True)
        schedule = Deque()
        for k, ts in tseries.iteritems():
            i = iter(ts)
            v, t = i.next()
            schedule.insort((t, v, i, k))
        # Consume the schedule and fill the resulting TSeries
        current = {}
        while len(schedule) > 0:
            t, v, i, k = schedule[0]
            now = t
            finished = []
            # consume events while time does not change
            while t == now:
                schedule.popleft()
                current[k] = v
                try:
                    next_v, next_t = i.next()
                except StopIteration:
                    finished.append(k)
                else:
                    schedule.insort((next_t, next_v, i, k))
                if len(schedule) == 0:
                    break
                t, v, i, k = schedule[0]
            # collect aggregated value into the aggregate tseries
            value = join_fnc(current, now)
            if value is not None:
                aggregate.collect(value, now)
            # delete finished time series
            for k in finished:
                del current[k]
        assert len(current) == 0
        return aggregate
        
    def derive(self, indicator_fnc):
        """This method creates a new time series based on the contents of this one. The values of 
        the resulting time series are given by the argument 'indicator_fnc' function which takes a 
        TSeries object (a buffer) as argument and should return the value of the derived TSeries.
        As an example, to obtain a derived TSeries whose values are the minimum of the original 
        TSeries (t1), we would do
            t2 = t1.derive(TSeries.min)
        """
        if not self.storing:
            raise ValueError("storing time series required")
        derived = TSeries(numeric=self.numeric, storing=True)
        buffer  = TSeries(numeric=self.numeric, storing=False)
        for value, time in self:
            buffer.collect(value, time)
            value = indicator_fnc(buffer)
            if value is not None:
                derived.collect(value, time)
        return derived
        
    # -----------------------------------------------
    # Mathematical operators
    def __neg__(self):
        return self.derive(lambda buff: -buff.last_value)
        
    def __pos__(self):
        return self.derive(lambda buff: buff.last_value)
        
    def __abs__(self):
        return self.derive(lambda buff: abs(buff.last_value))
        
    def __add__(self, x):
        if isinstance(x, TSeries):
            join_fnc = lambda v, t: v[0] + v[1] if len(v) == 2 else None
            return TSeries.join(join_fnc, self, x)
        return self.derive(lambda buff: buff.last_value + x)
        
    def __sub__(self, x):
        return self + (-x)
        
    def __mul__(self, x):
        if isinstance(x, TSeries):
            join_fnc = lambda v, t: v[0] * v[1] if len(v) == 2 else None
            return TSeries.join(join_fnc, self, x)
        return self.derive(lambda buff: buff.last_value * x)
        
    def __div__(self, x):
        if isinstance(x, TSeries):
            join_fnc = lambda v, t: v[0] / v[1] if len(v) == 2 else None
            return TSeries.join(join_fnc, self, x)
        return self.derive(lambda buff: buff.last_value / x)
        
    def __mod__(self, x):
        if isinstance(x, TSeries):
            join_fnc = lambda v, t: v[0] % v[1] if len(v) == 2 else None
            return TSeries.join(join_fnc, self, x)
        return self.derive(lambda buff: buff.last_value % x)
        
    def __pow__(self, x):
        if isinstance(x, TSeries):
            join_fnc = lambda v, t: v[0] ** v[1] if len(v) == 2 else None
            return TSeries.join(join_fnc, self, x)
        return self.derive(lambda buff: buff.last_value ** x)
        
    def __radd__(self, x):
        return self + x
        
    def __rsub__(self, x):
        return -self + x
        
    def __rmul__(self, x):
        return self * x
        
    def __rdiv__(self, x):
        return self.derive(lambda buff: x / buff.last_value)
        
    def __rmod__(self, x):
        return self.derive(lambda buff: x % buff.last_value)
        
    def __rpow__(self, x):
        return self.derive(lambda buff: x ** buff.last_value)
        
    # -----------------------------------------------
    # Plotting methods
    def run_chart(self, target=None, **kwargs):
        return get_plotter(target).run_chart(self.iter_times(), self.iter_values(), 
                                             self.numeric, **kwargs)
        
    def line_plot(self, target=None, **kwargs):
        return get_plotter(target).line_plot(self.iter_times(), self.iter_values(), **kwargs)
        